Skip to content

Feat/v0.9.0 byok mail - #84

Merged
yash-pouranik merged 7 commits into
mainfrom
feat/v0.9.0-byok-mail
Apr 8, 2026
Merged

Feat/v0.9.0 byok mail#84
yash-pouranik merged 7 commits into
mainfrom
feat/v0.9.0-byok-mail

Conversation

@yash-pouranik

@yash-pouranik yash-pouranik commented Apr 8, 2026

Copy link
Copy Markdown
Member

This PR introduces end-to-end support for Bring Your Own Key (BYOK) Resend Mail for urBackend projects (v0.9.0 milestone). This allows developers to securely integrate their own Resend API keys, fundamentally isolating both email deliverability (reputation) and rate-limiting from the platform's core global infrastructure.

It includes full routing for transactional API emails, as well as seamlessly hijacking systemic authentication emails (like Sign-Up and Password Reset OTPs) and routing them through the project's own custom verified Resend domains.

🚀 Key Features

  • Encrypted API Key Storage: Added project-level resendApiKey using the secure { encrypted, iv, tag } AES-256-GCM pattern. The API keys are never echoed back to the dashboard, preserving a zero-trust model.
  • Custom Sender Domains: A new resendFromEmail field allows devs to provide their verified SMTP sender alias (e.g., App Team <info@myapp.com>). We enforce strict Regex validation on save to ensure upstream compliance.
  • Intelligent Routing: Both POST /api/mail/send and the BullsMQ authEmailQueue.js now dynamically initialize scoped Resend SDK clients if a valid BYOK config exists, completely masking urBackend's overarching infrastructure from end-users.
  • Strict Rate Limiting & Rollbacks: Maintained the Redis-backed project:mail:count monthly quotas. Specifically implemented a quota rollback (redis.decr) guard that safely refunds the quota if delivery fails due to invalid parameters or Resend downtime.

🧪 Testing

  • Passed all 69 Apps > Public-API endpoints.
  • Passed all 86 Apps > Dashboard-API endpoints.
  • Verified Redis INCR/DECR quota guard logic handles edge-case crashes properly.
  • Confirmed the dashboard config PATCH validates without exposing secret keys in response payload.


Built with ❤️ for urBackend.

Summary by CodeRabbit

  • New Features
    • Added email sending API endpoint for projects.
    • Projects can now configure a custom Resend API key (bring your own key) and sender email address.
    • Monthly mail usage limit enforcement (100 emails per month default).
    • Auth verification and password reset emails now respect custom Resend configurations when available.
    • New project settings UI for managing custom mail settings and monitoring usage.

Copilot AI review requested due to automatic review settings April 8, 2026 09:42
Comment thread apps/dashboard-api/src/controllers/project.controller.js Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds BYOK (Bring Your Own Key) support for Resend-based email sending per project, including routing auth OTP emails through the project’s own Resend key/domain and exposing a new /api/mail/send endpoint with monthly quota enforcement.

Changes:

  • Add project-level Resend configuration (resendApiKey encrypted + resendFromEmail) with dashboard update flow and sanitized responses.
  • Route auth OTP queue emails through a per-project Resend client when configured (fallback to global key otherwise).
  • Introduce public-api /api/mail/send with Zod validation and Redis-backed monthly usage limits + rollback on failures.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/common/src/utils/input.validation.js Adds sendMailSchema for validating /api/mail/send payloads.
packages/common/src/utils/emailService.js Extends OTP email sending to optionally use a BYOK Resend client/from address.
packages/common/src/queues/authEmailQueue.js Loads per-project BYOK config (decrypts key) and passes it into OTP mail sender.
packages/common/src/models/Project.js Adds resendApiKey (encrypted object) and resendFromEmail to Project schema.
packages/common/src/index.js Exports sendMailSchema from the common package.
apps/web-dashboard/src/pages/ProjectSettings.jsx Adds UI controls for saving Resend BYOK key + from address and displays key status.
apps/public-api/src/utils/mailLimit.js Introduces month key + TTL helpers and a default monthly mail limit constant.
apps/public-api/src/routes/mail.js Adds /api/mail/send route with API key + secret key enforcement middleware.
apps/public-api/src/controllers/userAuth.controller.js Adds projectId into auth email queue jobs so BYOK config can be loaded.
apps/public-api/src/controllers/mail.controller.js Implements transactional mail sending with quota reservation/rollback and BYOK routing.
apps/public-api/src/app.js Registers the new /api/mail route.
apps/public-api/src/tests/mail.controller.test.js Adds controller-level tests for BYOK/default routing and limit enforcement.
apps/dashboard-api/src/controllers/project.controller.js Sanitizes resendApiKey from responses, adds key presence flag, and supports updating BYOK fields.
AGENTS.md Updates milestone notes with webhook system completion details.

Comment on lines +65 to +72
const limit = getMonthlyMailLimit(req.project);
const { count, key } = await reserveMonthlyMailSlot(projectId, limit);
consumedQuotaKey = key;

const project = await loadProjectMailConfig(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found." });
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monthly quota is reserved (INCR) before several early return paths (e.g., project not found / missing provider key). Those successful returns bypass the catch block, so the quota is never refunded and usage is incorrectly consumed. Consider moving reserveMonthlyMailSlot() after all non-delivery early exits, or converting these early returns into thrown errors so the rollback (DECR) always runs.

Copilot uses AI. Check for mistakes.

const loadProjectMailConfig = async (projectId) => {
return Project.findById(projectId)
.select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag")

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadProjectMailConfig() only selects resendApiKey.*, but sendMail later reads project.resendFromEmail to build the From header. Since resendFromEmail isn’t selected, BYOK sends will always fall back to onboarding@resend.dev even when a custom sender is configured. Include resendFromEmail in the projection.

Suggested change
.select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag")
.select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag resendFromEmail")

Copilot uses AI. Check for mistakes.
return res.status(404).json({ error: "Project not found." });
}

const decryptedByokKey = decrypt(project.resendApiKey);

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decrypt(project.resendApiKey) is called even when resendApiKey is null/undefined. The current decrypt() implementation logs "Decryption failed..." on invalid input and returns null, so projects without BYOK will spam misleading decryption errors. Guard with an object/field presence check before calling decrypt().

Suggested change
const decryptedByokKey = decrypt(project.resendApiKey);
const encryptedByokKey =
typeof project.resendApiKey === "string" && project.resendApiKey.trim().length > 0
? project.resendApiKey
: null;
const decryptedByokKey = encryptedByokKey ? decrypt(encryptedByokKey) : null;

Copilot uses AI. Check for mistakes.
{ new: true },
{
new: true,
select:

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findOneAndUpdate is passed an option named select, but Mongoose projections are applied via .select() chaining or the projection option. If select is ignored, resendApiKey remains unselected (fields are select:false), causing sanitizeProjectResponse() to set hasResendApiKey to false even after saving a key. Apply projection correctly so the response reflects the updated key state.

Suggested change
select:
projection:

Copilot uses AI. Check for mistakes.
Comment on lines +1109 to +1112
const senderRegex = /^(?:.*<)?[^\s@]+@[^>\s@]+\.[^>\s@]+(?:>)?$/;
if (!senderRegex.test(trimmedFrom)) {
return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App <me@domain.com>')." });
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The senderRegex currently allows a trailing > without a matching < (e.g., me@domain.com>), because < is optional but > is also optional independently. That can store invalid From values that Resend may reject. Consider a stricter pattern that either matches a plain email, or Display Name <email> with both brackets present.

Suggested change
const senderRegex = /^(?:.*<)?[^\s@]+@[^>\s@]+\.[^>\s@]+(?:>)?$/;
if (!senderRegex.test(trimmedFrom)) {
return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App <me@domain.com>')." });
}
const senderRegex =
/^(?:[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+|[^<>]+<\s*[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+\s*>)$/;
if (!senderRegex.test(trimmedFrom)) {
return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App <me@domain.com>')." });
}

Copilot uses AI. Check for mistakes.
Comment on lines +307 to +320
type="email"
className="input-field"
placeholder="e.g. Acme <info@acme.com>"
value={resendFromEmailValue}
onChange={(e) => setResendFromEmailValue(e.target.value)}
style={{
width: "100%",
padding: "12px",
background: "var(--color-bg-input)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
color: "#fff",
}}
/>

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This input uses type="email" but the placeholder/validation allow values like Acme <info@acme.com>, which are not valid HTML email inputs and can trigger built-in browser validation issues. Use type="text" (optionally with your own validation/help text) or split into separate name + email fields.

Copilot uses AI. Check for mistakes.
Comment on lines 1 to +5
const { Queue, Worker } = require('bullmq');
const connection = require('../config/redis');
const { sendAuthOtpEmail } = require('../utils/emailService');
const Project = require('../models/Project');
const { decrypt } = require('../utils/encryption');

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authEmailQueue worker now queries MongoDB (Project.findById) to load BYOK config, but the worker is still created at module import time. In apps/public-api, @urbackend/common queues are imported before connectDB() runs, so the worker can start processing jobs without an established mongoose connection. Consider deferring worker initialization until after DB connect, or explicitly waiting for mongoose readiness inside the processor before querying.

Copilot uses AI. Check for mistakes.
@coderabbitai

coderabbitai Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@yash-pouranik has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 27 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 1 minutes and 27 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6bde532b-37c1-4ac6-8224-b0f154840ae0

📥 Commits

Reviewing files that changed from the base of the PR and between 68c62f8 and 72ba39d.

📒 Files selected for processing (2)
  • apps/public-api/src/controllers/mail.controller.js
  • packages/common/src/index.js
📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive email-sending feature with Bring Your Own Key (BYOK) support using Resend. It includes per-project monthly rate limiting (100 emails), encrypted API key storage, auth email worker improvements, frontend UI for mail configuration, and corresponding tests.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md
Expanded v0.9.0 webhook system plan with MongoDB models, dispatcher utility, and operational details (BullMQ queue, exponential backoff, HMAC-SHA256 signatures).
Dashboard API — Resend Integration
apps/dashboard-api/src/controllers/project.controller.js
Extended project controller to handle encrypted resendApiKey storage and resendFromEmail configuration; added input validation for email format and max length (255 chars); updated cache retrieval and response projection.
Public API — Mail Endpoint
apps/public-api/src/controllers/mail.controller.js, apps/public-api/src/routes/mail.js
Added new mail controller with POST /send route enforcing secret-key auth, monthly rate limiting via Redis (100 emails/project/month), BYOK fallback to default key, dynamic from-address override, quota reservation/rollback, and conditional HTML/text inclusion.
Public API — Mail Utilities
apps/public-api/src/utils/mailLimit.js
Introduced utility functions for generating UTC month keys, computing month-end TTL, and retrieving fixed monthly mail limit (100 emails).
Public API — App Setup & Auth Integration
apps/public-api/src/app.js, apps/public-api/src/controllers/userAuth.controller.js
Registered mail route under /api/mail with standard middleware; updated auth email queue payloads to include projectId for BYOK lookup.
Common Package — Email Service & Queue
packages/common/src/queues/authEmailQueue.js, packages/common/src/utils/emailService.js
Refactored auth email worker to support deferred initialization (initAuthEmailWorker); added BYOK key/from-address decryption and fallback; updated sendAuthOtpEmail signature to accept optional byokKey and byokFrom.
Common Package — Data Model & Validation
packages/common/src/models/Project.js, packages/common/src/utils/input.validation.js, packages/common/src/index.js
Added resendApiKey and resendFromEmail fields to Project schema; introduced sendMailSchema for validating email payload (to, subject, html/text with at least one content field); exported new validation schema and worker initializer.
Web Dashboard — UI
apps/web-dashboard/src/pages/ProjectSettings.jsx
Added "Custom Mail (Resend BYOK)" settings card with API key and from-email inputs, status indicator, save handler with conditional payload building, and no-op prevention.
Tests
apps/public-api/src/__tests__/mail.controller.test.js
Added comprehensive test suite covering BYOK key resolution, default provider fallback, monthly limit enforcement (429 on 101st email), Redis usage tracking, and error handling.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant API as Public API<br/>(Mail Controller)
    participant DB as MongoDB<br/>(Project)
    participant Redis as Redis<br/>(Rate Limit)
    participant Resend as Resend API

    Client->>API: POST /api/mail/send<br/>{to, subject, html/text}
    activate API
    API->>API: Validate auth (secret key)<br/>& request schema
    API->>DB: Load project config<br/>(resendApiKey, resendFromEmail)
    activate DB
    DB-->>API: Project data
    deactivate DB
    
    alt BYOK Key Present
        API->>API: Decrypt resendApiKey
        API->>API: Select BYOK key +<br/>resendFromEmail
    else No BYOK Key
        API->>API: Use env RESEND_API_KEY<br/>+ EMAIL_FROM
    end

    API->>Redis: INCR month_quota_key
    activate Redis
    Redis-->>API: Current usage (e.g., 1)
    deactivate Redis
    
    alt Usage ≤ Limit (100)
        API->>Redis: EXPIRE month_quota_key
        activate Redis
        deactivate Redis
        
        API->>Resend: emails.send<br/>(to, from, subject, html/text)
        activate Resend
        Resend-->>API: Success {id}
        deactivate Resend
        
        API-->>Client: 200 {provider, id,<br/>monthlyUsage, limit}
    else Usage > Limit
        API->>Redis: DECR month_quota_key<br/>(rollback)
        activate Redis
        deactivate Redis
        
        API-->>Client: 429 {error: "Monthly<br/>mail limit exceeded"}
    end
    deactivate API
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


🐰 A fluffy feat of mail so fine,
With BYOK keys and Redis time,
Rate limits set at one-oh-oh,
From Resend flows both high and low,
The inbox hops with joyful gleam! 📬✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/v0.9.0 byok mail' clearly identifies the main feature being added (BYOK mail support for v0.9.0), matching the PR objectives and the comprehensive changes to mail handling, project configuration, and email routing across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v0.9.0-byok-mail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/common/src/index.js (1)

98-103: ⚠️ Potential issue | 🔴 Critical

Missing export for initAuthEmailWorker.

The initAuthEmailWorker function is imported but not added to module.exports. Since apps/public-api/src/app.js imports it from @urbackend/common, this will cause a runtime error. Follow the pattern used for initWebhookWorker (line 102).

🐛 Proposed fix
   authEmailQueue,
+  initAuthEmailWorker,
   emailQueue,
   webhookQueue,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/index.js` around lines 98 - 103, The module currently
exports initWebhookWorker but forgot to export initAuthEmailWorker; open the
exports list in packages/common/src/index.js and add initAuthEmailWorker
alongside initWebhookWorker (and related exports like enqueueWebhookDelivery,
authEmailQueue) so that initAuthEmailWorker is available to consumers such as
apps/public-api; ensure the symbol name matches the imported identifier used
elsewhere.
🧹 Nitpick comments (2)
AGENTS.md (1)

170-176: Consider separating “planned” vs “completed” v0.9.0 items for clarity.

Having Planned for v0.9.0 immediately followed by Already done for the same scope can be confusing during handoffs. Consider moving completed webhook items into a dedicated “Shipped in v0.9.0” section and leaving only pending items under “Planned.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` around lines 170 - 176, The documentation mixes planned and
completed items for v0.9.0; create two separate header sections—"Planned for
v0.9.0" and "Shipped in v0.9.0"—then move the completed webhook entries (Model:
packages/common/src/models/Webhook.js, Delivery log:
packages/common/src/models/WebhookDelivery.js, Dispatcher:
apps/public-api/src/utils/webhookDispatcher.js, Queue: BullMQ + existing Redis
connection, Retry: exponential backoff, max 5 attempts, stop on 4xx, Signature:
HMAC-SHA256 in X-urBackend-Signature header) into the "Shipped in v0.9.0"
section and leave only outstanding tasks under "Planned for v0.9.0" to make
handoffs clearer.
apps/public-api/src/__tests__/mail.controller.test.js (1)

65-115: These tests don't pin the actual BYOK routing yet.

They only assert the controller's JSON. A regression where the controller reports provider: 'byok' but still constructs Resend with the default key—or where emails.send() fails after quota reservation without a decr() rollback—would still pass. Please add assertions on the Resend constructor / send payload and one send-failure rollback case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public-api/src/__tests__/mail.controller.test.js` around lines 65 - 115,
The tests must assert actual BYOK routing and rollback on send failure: update
the tests for mailController.sendMail to mock/spy the Resend constructor and the
emails.send method (referencing the Resend constructor and emails.send) and
assert that when decrypt returns a key the Resend instance was created with that
BYOK key and when decrypt returns null it was created with the default key;
additionally add a test where emails.send is mocked to throw and confirm
redis.decr is called (rollback) and the controller returns the appropriate error
response (use mailController.sendMail, redis.decr, and emails.send in the
assertions).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/public-api/src/controllers/mail.controller.js`:
- Around line 33-46: Replace the split INCR/EXPIRE/DECR sequence with a single
atomic Redis EVAL (Lua) call so increment, TTL initialization, limit check and
potential rollback happen inside Redis; implement a Lua script invoked with
redis.eval that: 1) increments KEYS[1], 2) if the resulting value is 1 sets
EXPIRE to ARGV[1] (ttlSeconds), 3) if the value > tonumber(ARGV[2]) (limit)
decrements the key and returns an error indicator, otherwise returns the new
count; call the script with KEYS = [key] and ARGV = [ttlSeconds, limit], throw
the 429 error in JS when the script indicates limit exceeded, and keep existing
key naming (project:mail:count:{projectId}:{YYYY-MM}) and variable names (redis,
key, ttlSeconds, limit, consumedQuotaKey) so caller logic remains unchanged.
- Around line 49-56: The handler sendMail currently returns raw { error: ... }
and err.message on early returns and in the catch, breaking the public API
contract; import and use the AppError class and always respond with the standard
envelope { success: boolean, data: {}, message: string } on failures. Replace
the secret-key check return (and other early returns around the sendMail
function, including the blocks referenced at 61-67, 81-83, 121-135) so they
either throw new AppError("Forbidden. This action requires a Secret Key
(sk_live_...).", 403) or call res.status(...).json({ success: false, data: {},
message: "..." }) constructed from AppError; update the catch block to map any
caught error to an AppError (don’t expose err.message or DB errors) and respond
with res.status(appErr.statusCode||500).json({ success: false, data: {},
message: appErr.message }). Ensure consumedQuotaKey cleanup/logic remains
unchanged.

In `@packages/common/src/index.js`:
- Around line 25-26: The destructuring import mistakenly declares
initAuthEmailWorker twice which is a syntax error; update the destructuring to
include each exported symbol only once (e.g., keep authEmailQueue and
initAuthEmailWorker and remove the duplicate initAuthEmailWorker) so the
require("./queues/authEmailQueue") statement correctly binds authEmailQueue and
initAuthEmailWorker without duplication.

---

Outside diff comments:
In `@packages/common/src/index.js`:
- Around line 98-103: The module currently exports initWebhookWorker but forgot
to export initAuthEmailWorker; open the exports list in
packages/common/src/index.js and add initAuthEmailWorker alongside
initWebhookWorker (and related exports like enqueueWebhookDelivery,
authEmailQueue) so that initAuthEmailWorker is available to consumers such as
apps/public-api; ensure the symbol name matches the imported identifier used
elsewhere.

---

Nitpick comments:
In `@AGENTS.md`:
- Around line 170-176: The documentation mixes planned and completed items for
v0.9.0; create two separate header sections—"Planned for v0.9.0" and "Shipped in
v0.9.0"—then move the completed webhook entries (Model:
packages/common/src/models/Webhook.js, Delivery log:
packages/common/src/models/WebhookDelivery.js, Dispatcher:
apps/public-api/src/utils/webhookDispatcher.js, Queue: BullMQ + existing Redis
connection, Retry: exponential backoff, max 5 attempts, stop on 4xx, Signature:
HMAC-SHA256 in X-urBackend-Signature header) into the "Shipped in v0.9.0"
section and leave only outstanding tasks under "Planned for v0.9.0" to make
handoffs clearer.

In `@apps/public-api/src/__tests__/mail.controller.test.js`:
- Around line 65-115: The tests must assert actual BYOK routing and rollback on
send failure: update the tests for mailController.sendMail to mock/spy the
Resend constructor and the emails.send method (referencing the Resend
constructor and emails.send) and assert that when decrypt returns a key the
Resend instance was created with that BYOK key and when decrypt returns null it
was created with the default key; additionally add a test where emails.send is
mocked to throw and confirm redis.decr is called (rollback) and the controller
returns the appropriate error response (use mailController.sendMail, redis.decr,
and emails.send in the assertions).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86dabb08-4d26-454b-8e0a-14943dbf73de

📥 Commits

Reviewing files that changed from the base of the PR and between 304529b and 68c62f8.

📒 Files selected for processing (14)
  • AGENTS.md
  • apps/dashboard-api/src/controllers/project.controller.js
  • apps/public-api/src/__tests__/mail.controller.test.js
  • apps/public-api/src/app.js
  • apps/public-api/src/controllers/mail.controller.js
  • apps/public-api/src/controllers/userAuth.controller.js
  • apps/public-api/src/routes/mail.js
  • apps/public-api/src/utils/mailLimit.js
  • apps/web-dashboard/src/pages/ProjectSettings.jsx
  • packages/common/src/index.js
  • packages/common/src/models/Project.js
  • packages/common/src/queues/authEmailQueue.js
  • packages/common/src/utils/emailService.js
  • packages/common/src/utils/input.validation.js

Comment on lines +33 to +46
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, ttlSeconds);
}

if (count > limit) {
await redis.decr(key);
const err = new Error("Monthly mail limit exceeded.");
err.statusCode = 429;
err.limit = limit;
throw err;
}

return { count, key };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the monthly quota reservation atomic.

Line 33 and Line 35 split first-use initialization across INCR and EXPIRE. If the process dies or expire() throws in between, the counter is left without TTL; later requests see count > 1 and never repair it, and the outer catch cannot undo the leaked slot because consumedQuotaKey is assigned only after this helper returns. Move the increment/TTL/limit enforcement into one atomic Redis unit instead of separate commands. Based on learnings, Redis key patterns must not be changed. Existing patterns: project:mail:count:{projectId}:{YYYY-MM} (TTL = end of month).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public-api/src/controllers/mail.controller.js` around lines 33 - 46,
Replace the split INCR/EXPIRE/DECR sequence with a single atomic Redis EVAL
(Lua) call so increment, TTL initialization, limit check and potential rollback
happen inside Redis; implement a Lua script invoked with redis.eval that: 1)
increments KEYS[1], 2) if the resulting value is 1 sets EXPIRE to ARGV[1]
(ttlSeconds), 3) if the value > tonumber(ARGV[2]) (limit) decrements the key and
returns an error indicator, otherwise returns the new count; call the script
with KEYS = [key] and ARGV = [ttlSeconds, limit], throw the 429 error in JS when
the script indicates limit exceeded, and keep existing key naming
(project:mail:count:{projectId}:{YYYY-MM}) and variable names (redis, key,
ttlSeconds, limit, consumedQuotaKey) so caller logic remains unchanged.

Comment on lines +49 to +56
module.exports.sendMail = async (req, res) => {
let consumedQuotaKey = null;
try {
if (req.keyRole !== "secret") {
return res.status(403).json({
error: "Forbidden. This action requires a Secret Key (sk_live_...).",
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return the standard public API error envelope here.

The success path is already shaped correctly, but the early returns and catch block switch back to { error: ... } and raw err.message. That breaks the public API contract and can leak upstream/Mongo details on unexpected failures. Please route these through AppError and respond with the standard { success, data, message } shape on errors too. As per coding guidelines, apps/public-api/src/**/*.js: All API endpoints must return response format { success: bool, data: {}, message: "" } and use AppError class for errors. Never raw throw, never expose MongoDB errors to client.

Also applies to: 61-67, 81-83, 121-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public-api/src/controllers/mail.controller.js` around lines 49 - 56, The
handler sendMail currently returns raw { error: ... } and err.message on early
returns and in the catch, breaking the public API contract; import and use the
AppError class and always respond with the standard envelope { success: boolean,
data: {}, message: string } on failures. Replace the secret-key check return
(and other early returns around the sendMail function, including the blocks
referenced at 61-67, 81-83, 121-135) so they either throw new
AppError("Forbidden. This action requires a Secret Key (sk_live_...).", 403) or
call res.status(...).json({ success: false, data: {}, message: "..." })
constructed from AppError; update the catch block to map any caught error to an
AppError (don’t expose err.message or DB errors) and respond with
res.status(appErr.statusCode||500).json({ success: false, data: {}, message:
appErr.message }). Ensure consumedQuotaKey cleanup/logic remains unchanged.

Comment thread packages/common/src/index.js Outdated
@yash-pouranik

Copy link
Copy Markdown
Member Author

@coderabbitai good to merge??

@coderabbitai

coderabbitai Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR status, checks, and reviews
echo "=== PR STATUS ==="
gh pr view 84 --repo geturbackend/urBackend --json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup

echo ""
echo "=== OPEN REVIEW COMMENTS ==="
gh pr view 84 --repo geturbackend/urBackend --json reviews,comments

echo ""
echo "=== PR CHECKS ==="
gh pr checks 84 --repo geturbackend/urBackend

echo ""
echo "=== REVIEW THREADS ==="
gh api graphql -f query='
{
  repository(owner: "geturbackend", name: "urBackend") {
    pullRequest(number: 84) {
      reviewThreads(first: 50) {
        nodes {
          isResolved
          isOutdated
          comments(first: 1) {
            nodes {
              author { login }
              body
              path
              line
            }
          }
        }
      }
    }
  }
}'

Length of output: 51494


@yash-pouranik Not quite yet — there are 3 unresolved issues flagged during review that should be addressed before merging:


🔴 Critical (Build-breaking)

packages/common/src/index.js — Line 26: initAuthEmailWorker is destructured twice in the same const declaration, which is a syntax error and will break the build at startup.

-const { authEmailQueue,
-  initAuthEmailWorker, initAuthEmailWorker } = require("./queues/authEmailQueue");
+const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue");

🟠 Major

  1. apps/public-api/src/controllers/mail.controller.js — Lines 33–46: The INCR + EXPIRE quota reservation is non-atomic. If the process crashes between the two calls, the Redis counter is left without a TTL and the slot leaks permanently. This should use a Lua EVAL script to make it atomic.

  2. apps/public-api/src/controllers/mail.controller.js — Lines 49–56, 61–67, 81–83, 121–135: Early returns and the catch block return raw { error: ... } / err.message, which breaks the public API contract ({ success, data, message }) and can leak internal error details.


🟡 Worth noting

packages/common/src/queues/authEmailQueue.js — Line 5 (Copilot, unresolved): The worker is initialized at module-import time and queries MongoDB, but in apps/public-api, the common package is imported before connectDB() runs. Consider deferring worker initialization until after the DB connection is established.


All CI checks (CodeQL, linting, tests) are green ✅ and the branch is mergeable from GitHub's perspective, but the critical syntax error alone is enough to block a safe merge. Once these are fixed, the PR should be in good shape!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants